Skip to content

Cache C# Compilation Emit by Project Version to avoid excessive background churn - #20119

Open
xperiandri wants to merge 3 commits into
dotnet:mainfrom
xperiandri:revert-20080-t-gro-net11-upgrade
Open

Cache C# Compilation Emit by Project Version to avoid excessive background churn#20119
xperiandri wants to merge 3 commits into
dotnet:mainfrom
xperiandri:revert-20080-t-gro-net11-upgrade

Conversation

@xperiandri

Copy link
Copy Markdown
Contributor

Fixes #20118.

Description

When C# projects change, Roslyn creates new Compilation instances. The F# IDE integration currently uses a ConditionalWeakTable to cache the emitted PE reference from these compilations. Because new Compilation objects are continually created by Roslyn, the weak table cache misses, causing repeated, expensive metadata-only emissions (Compilation.Emit(metadataOnly=true)). This contributes to UI latency and background CPU churn.

Solution

This PR adds an emitCache: ConcurrentDictionary<ProjectId, ConcurrentDictionary<VersionStamp, FSharpReferencedProject>> to FSharpProjectOptionsManager.fs.
This allows caching based on a stable identifier (projectId and project.Version), avoiding unnecessary re-emission of assemblies when the underlying C# project hasn't functionally changed. The cache properly invalidates and handles CancellationToken via cancellableTask { ... }.

abonie and others added 3 commits July 29, 2026 10:48
- Add ActiveDocumentDetection module (IVsMonitorSelection-based helper)
- Gate UnusedDeclarationsAnalyzer to active document
- Gate SimplifyNameDiagnosticAnalyzer to active document
- Gate FSharpInlayHintsService to active document
- Gate UnusedOpensDiagnosticAnalyzer to active document
- Add ActiveDocumentDetection.fs to FSharp.Editor.fsproj

Fixes dotnet#20114
@xperiandri
xperiandri requested a review from a team as a code owner August 2, 2026 22:45
@github-actions github-actions Bot added ⚠️ Affects-Agent-Config Tooling check: PR modifies AI agent instructions or workflows ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Restore Tooling check: PR touches NuGet packages or feeds ⚠️ Scope-Review-Needed Tooling check: PR scope exceeds title/description labels Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Build-Infra, Affects-Restore, Affects-Agent-Config, Scope-Review-Needed
Affects-Build-Infra: modifies eng/common build scripts and props
Affects-Restore: changes eng/Versions.props and Version.Details.xml
Affects-Agent-Config: modifies .github/skills/pr-description/SKILL.md
Scope-Review-Needed: title claims "Cache C# Compilation Emit" but files are all eng/ infra

Generated by PR Tooling Safety Check · opus46 3.2M ·

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 This review was generated by AI (@expert-reviewer agent). Findings may contain inaccuracies — please verify independently.

Review scoped to the substantive behavioural change in FSharpProjectOptionsManager.fs (the new C# emit cache). The remaining diff is largely an arcade/eng/common and net11→net10 TargetFramework revert plus analyzer active-document gating, which were not reviewed in depth. Three correctness/performance concerns on the cache are noted inline; the most important is the unbounded growth of the per-project version cache, which can turn the intended optimization into a memory leak under the very churn scenario it targets.

// However, when C# projects churn, Roslyn creates new Compilation instances with the same project ID and version,
// which makes ConditionalWeakTable defeat the purpose. We use a nested ConcurrentDictionary keyed by ProjectId and VersionStamp
// to map to the FSharpReferencedProject, ensuring stable references across churns.
let emitCache = ConcurrentDictionary<ProjectId, ConcurrentDictionary<VersionStamp, FSharpReferencedProject>>()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unbounded memory growth (potential leak). The inner versionCache is only pruned when the whole project is removed from the solution (emitCache.TryRemove(projectId)); individual VersionStamp entries are never evicted. Each entry strongly holds an FSharpReferencedProject whose DelayedILModuleReader retains the emitted metadata MemoryStream once realized (the code deliberately never disposes it). Under exactly the C#-churn scenario this PR targets, every edit yields a new stamp and appends a new entry, so this dictionary grows without bound for the life of the project. This replaces the previous GC-collectable ConditionalWeakTable<Compilation,_> (entries freed once the Compilation was collected) with a strongly-rooted cache. Consider keeping only the latest stamp per project (clear/replace on a new stamp) or bounding the cache size (LRU).

let createPEReference (referencedProject: Project) (comp: Compilation) ct =
cancellableTask {
let projectId = referencedProject.Id
let! stamp = referencedProject.GetDependentVersionAsync(ct)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetDependentVersionAsync changes on any text edit to this project or to any project it transitively references, so the cache will miss — and trigger a fresh, expensive metadata Emit — far more often than needed, and (combined with the unbounded versionCache above) accumulates entries faster. For a metadata-only PE reference the meaningful key is the semantic version: GetDependentSemanticVersionAsync changes only when the referenced project's public surface changes, which is what actually invalidates the emitted metadata. Also, the PR description states the key is project.Version, which does not match this call — please reconcile description and implementation.

weakPEReferences.Add(comp, fsRefProj)
versionCache.[stamp] <- fsRefProj
return fsRefProj
| _ ->

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This first-seen initialization path races: two threads hitting a not-yet-cached projectId each construct a separate versionCache, and emitCache.[projectId] <- versionCache unconditionally overwrites, discarding the other thread's just-added entry and forcing a redundant Emit. Use let versionCache = emitCache.GetOrAdd(projectId, fun _ -> ConcurrentDictionary<_,_>()) and then follow a single code path. That also lets you delete the ~55 lines of getStream/tryStream logic duplicated verbatim between this branch (lines 218-274) and the branch above (lines 153-209); keeping two identical copies risks them silently diverging when one is later fixed and the other is missed.

@T-Gro T-Gro added the AI-reviewed PR reviewed by AI review council label Aug 3, 2026
@T-Gro
T-Gro self-requested a review August 3, 2026 19:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Agent-Config Tooling check: PR modifies AI agent instructions or workflows ⚠️ Affects-Build-Infra Tooling check: PR touches build infrastructure ⚠️ Affects-Restore Tooling check: PR touches NuGet packages or feeds AI-reviewed PR reviewed by AI review council ⚠️ Scope-Review-Needed Tooling check: PR scope exceeds title/description

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

[Ide] Excessive Compilation.Emit due to Roslyn Compilation churn for C# references

3 participants